Repository navigation
Onda 6: validação diferencial das 52 libcalls, clippy zerado e 5 bugs corrigidos - #27
Merged
Merged
Conversation
`cargo clippy --all-targets -- -D warnings` was failing with 23 errors in the files added by the libcall waves. All of them are mechanical: - `schema.rs`: 11 `explicit_auto_deref` — pass the smart pointer and let the coercion happen at the call. - `arrays.rs`: 5 `useless_asref` on `JsString` (it implements `AsRef<JsString>`, so `as_ref()` was the identity) plus one `box_collection` on `ArrayData::properties`. The `Box` there is deliberate — it keeps a property-free array at its small size, exactly like the neighbouring `sparse` field — so that one gets a scoped `#[allow]` with the rationale instead of widening every `ArrayData`. - `json_schema.rs`: one `useless_asref` and a `collapsible_if` folded into a let-chain, which this crate already uses elsewhere. - `effect_http.rs`: two `useless_asref`. - `regex.rs`: one `needless_borrow`. No semantic change: every rewritten comparison keeps the same `PartialEq` impl and the same receiver type. `cargo test` stays at 260 passing.
Wave 4 of the libcall plan landed `cp.execFile` end to end — the IR op, its validator, `lowerExecFileCall` with the full callback/options fence ladder, the Rust emitter, the runtime, and the corpus programs 2911/2912 — but never claimed the member. `builtinModuleFnOf` found no `child_process.execFile` row, so the fence in lower-calls.ts refused the call with SC2020 before `lowerBuiltinModuleCall`'s dispatch could reach the lowering. The whole slice was dead code and both corpus programs were red in every lane. - Add the `execFile` row to the child_process surface table, the same way spawn/fork/execFileSync carry canonical shapes while their real call completion is special-cased in `lowerBuiltinModuleCall`. - Drop the stale "the callback form has no lowering — promisify it" fence hint, which now contradicts the implementation. - Teach `sideEffectFreeOptionValue` about arithmetic over numeric LITERALS so `maxBuffer: 64 * 1024 * 1024` (2912) is accepted. Operands are restricted to literals on purpose: an identifier operand is a side-effect-free read, but arithmetic on it would run valueOf. - Mark 2911/2912 `// @rust-only`: `cp.execFile` is a Rust-lane row in BACKEND_LIB_CALLS, so the C and LLVM lanes would refuse it. That is the upstream drift the plan records and does not fix. - Regenerate surface-manifest.json (the row becomes status "static", backends ["rust"]) and the child-process.ts diagnostics snapshot, which now shows the per-site execFile/fork fences the fixture was written for instead of one blanket member refusal. Verified: both corpus programs pass the Rust differential lane, and a minimal execFile program's stdout/stderr/exit match Node byte for byte.
The zlib slice had NO compiling corpus coverage: 2921 — the one program naming all eight calls — was red in every lane on `const gzipAlias = gzip;`. An immutable alias of an OVERLOADED table-backed builtin is a principled frontend refusal (SC2007 on the two call signatures, SC1090 on library functions as values), and builtin aliasing has its own fixture in 2913, so the alias was never this program's subject. Call `gzip` directly and the rest of the file is sound. - 2921: drop the alias, mark `// @rust-only`. All eight spellings are Rust-lane rows in BACKEND_LIB_CALLS, so C and LLVM would refuse them — the upstream drift the plan records rather than fixes. - 3278: the lifecycle contract the plan asks for and 2921 does not prove. Per codec: the callback is SCHEDULED (never inline — checked against a `returned` flag set after the call), fires EXACTLY ONCE (a counter printed with every settle), and a codec failure arrives as argument ONE with the call itself not throwing. Covers all four decoders over the same fixed garbage, the distinct truncated-gzip failure, unzip's auto-detection of both wrappers, five concurrent deflates each firing once, the empty-input stream, and crc32's seeded chaining plus its ERR_OUT_OF_RANGE. No compressed bytes are printed — they are zlib-version-dependent, so every encoder is proved through its decoder and only the fixed gzip framing is shown. Both programs pass the Rust differential lane (stdout, stderr and exit code byte-identical to Node 24) and between them name all eight libcalls.
Wave 2's sixteen crypto libcalls had no compiling corpus coverage. The only program that named them, 2890, is red (four separate unsupported forms), and the hash programs that do compile — 1534, 2820, 2821, 2870 — all use the FUSED chain, which the frontend folds into one hashDigestStr/hmacDigestStr call and which therefore never reaches a single handle libcall. Two programs, both `// @rust-only` (all sixteen rows are Rust-only in BACKEND_LIB_CALLS): 3279 — the incremental handle surface. Every handle is stored in a binding so the chain fast path stands down and the per-step calls run: createHash/createHmac with string and byte keys, update with string and bytes and with hex/utf8 input encodings, and both terminal digests. Digests are deterministic, so they print in full. `hash.copy()` is tested for real INDEPENDENCE: a shared prefix, then a divergent write on each side, each compared against a separately built expected digest; a copy taken and not written; and finalizing the copy without finalizing its origin. Terminal state pins Node's asymmetry exactly — Hash throws ERR_CRYPTO_HASH_FINALIZED on update, digest and copy after digest, while Hmac throws on update but answers an EMPTY string on a second digest. 3280 — pbkdf2Sync and the randomness primitives. pbkdf2 is deterministic, so its bytes print, and the one-iteration single-block derivation is cross-checked against HMAC(password, salt || INT(1)) built from the Hmac surface instead of a copied constant. Randomness is tested by INVARIANT, never by byte: randomFillSync returns the same view, a windowed fill leaves the bytes outside [offset, offset+size) at their marker, a zero-size fill touches nothing, the rest forms cover the whole buffer and from an offset, a Uint8Array target works, and 32 filled bytes are not all zero. randomInt is drawn 400 times against both arities, checked for range and integrality, and every value of a 3-wide range must appear — a stuck or modulo-biased generator fails that. Out-of-range fills, inverted, empty and fractional ranges assert their observable errors. Both pass the Rust differential lane and between them name all sixteen libcalls.
2961 and 2962 already reach `emitter.onFlex`/`emitFlex` and pass, but they only prove that a computed event name works at all. Wave 3's claim is stronger: the checked-dynamic path must keep the SAME lifecycle as the typed one. 3281 pins that, `// @rust-only` like both existing programs' family (the two rows are Rust-only in BACKEND_LIB_CALLS). Covered: registration order; prependListener landing in front; function IDENTITY (the same reference registered twice counts twice, fires twice, and `off` removes one registration at a time); `once` firing exactly once and leaving the remaining listeners intact; removal performed DURING an emit — the removed listener still runs for the in-flight emit and is gone for the next, because Node dispatches over a snapshot — plus the self-removing and removeAllListeners-during-emit variants; emit with no listener answering false; a LITERAL event name that the computed pattern matches reaching the same bucket as the computed spelling; payload arity through zero- and three-parameter listeners (a rest parameter cannot cross the boundary, so arity is covered by fixed arities); number and string payloads used as their real types on the far side; and the reserved `error` event staying on its own typed bucket with the Error object intact while flex listeners exist on the same emitter. Passes the Rust differential lane, so every one of those orderings is byte-identical to Node 24.
Running wave 4's own corpus programs for the first time turned up three mismatches against Node in the child-stdin writer. Two of them are fixed here; the third is an architectural gap recorded below. 2896-child-stdin-write-after-finish: the `Finished` dispatch cleared `error_listeners` before running the finish listeners. The program writes from inside its finish listener — Node's ERR_STREAM_WRITE_AFTER_END, which must arrive on the stream's `error` event — so `child_writer_fail` found an empty listener list, took its no-listener branch and threw the error UNCAUGHT. The program died mid-output: the error line and the child's own `exit` line never printed. The error listeners now survive finish, which is also what Node does. 2898-child-stdin-spawn-failure: the husk stdin handed back for a child that failed to spawn started `writable: false`, so a read before the error landed answered false where Node answers true (Node returns a live ChildProcess and reports ENOENT on a later turn). `children_dispatch_one` already destroys the writer immediately before it delivers the error, so the read from inside the `error` listener still answers false — the asymmetry the program checks. STILL RED, not fixed here: 2894-child-stdin-backpressure. Node's `write` answers true for a 64 KiB + 1 write into an empty pipe because libuv attempts the write inline on the calling thread, the kernel absorbs the first 64 KiB, and only the 1-byte remainder counts against the high-water mark. This writer hands the whole buffer to its writer thread and scores backpressure by bytes handed over, so it answers false. The two agree on large writes and disagree exactly at the boundary. Matching Node needs an inline non-blocking write attempt on the main thread with only the remainder queued — a writer redesign, and one that needs a dependency decision, since `#![forbid(unsafe_code)]` rules out reaching for libc. `cargo test` stays at 260 passing and clippy stays clean; 2896 and 2898 now pass the Rust differential lane.
The last two fork/IPC libcalls without corpus coverage were `child.disconnect` (every existing program disconnects from the WORKER side with process.disconnect()) and the bare `child.send(message)` with no callback (every existing send passes one, which routes to child.sendCb). Covering them exposed two divergences in the IPC channel. `connected` was doing four jobs at once: the public flag, the send gate, the fire-once guard on the disconnect event, and the liveness bit that defers the child's exit event until the channel has drained. Because of the last two, disconnect() could not clear it — so it only sent a command to the channel thread, and `child.connected` kept answering true and `child.send` kept answering true until a thread round trip completed. Node closes the channel inside disconnect(): the next read is already false and the next send is already refused. Split the flag. `connected` is now the public, synchronous one, cleared inside disconnect(). A new `open` carries the drain state, so the exit event stays deferred and a message the worker thread had already framed before the local disconnect is still delivered — the EOF guarantee the plan asks for is unchanged, and the disconnect listeners still fire exactly once, now guarded on `open`. Second divergence: the `disconnect` EVENT arrived only on the channel thread's round trip, so it landed AFTER the callback of a send issued right after disconnect(). Node emits it on the tick following a local disconnect(), i.e. before that callback. disconnect() now takes its listeners and queues them itself; the later Disconnected event still fails the pending send callbacks but finds no listener left to call. 3282 pins all of it: a spawned child with no IPC slot reads as not connected (channel ABSENT) versus a forked child after disconnect (channel DISCONNECTED); the bare send and a burst of framed messages, all five of which the worker reports back even though the channel is torn down immediately after the reply; both send callbacks settling exactly once, the late one with the channel-closed error; and the worker writing its exit code twice so the parent can confirm the last write is the one it observes. All five fork corpus programs — 2946, 2948, 2949, 2963 and 3282 — pass the Rust differential lane. cargo test stays at 260 passing, clippy clean.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Onda 6 — validação das 52 libcalls
Executa a onda de validação do plano
.red/code/plans/2026-09-26-backend-libcall-parity.md. As cinco ondas de implementação landaram com os testes deferidos por decisão do mantenedor; esta é a primeira vez que o corpus das 52 libcalls realmente roda. O exercício encontrou seis bugs reais — cinco corrigidos aqui, um caracterizado e deixado aberto por exigir redesenho.Escopo respeitado: o diff não toca
packages/compiler/src/backend/c,packages/compiler/src/backend/llvmnempackages/runtime(runtime C).1. Clippy do runtime Rust zerado
cargo clippy --all-targets --locked -- -D warningsestava vermelho com 23 erros (o jobruntime_rustnão passava). Todos mecânicos, em arquivos das ondas novas:src/schema.rsexplicit_auto_derefsrc/arrays.rsuseless_asref(JsString: AsRef<JsString>)src/arrays.rsbox_collectionsrc/json_schema.rsuseless_asref+collapsible_ifsrc/effect_http.rsuseless_asrefsrc/regex.rsneedless_borrowSem mudança de semântica: cada comparação reescrita mantém o mesmo
PartialEqe o mesmo tipo de receptor. Obox_collectiondeArrayData::propertiesrecebeu#[allow]com justificativa em vez de virarVec: oBoxmantém o array sem propriedades no tamanho pequeno, igual ao camposparsevizinho — trocar aumentaria todoArrayDataem 16 bytes.cargo testpermanece em 260 passando.2. Bugs reais encontrados pelo corpus
cp.execFileinalcançável: lowering completo no frontend e no backend, mas o membro nunca foi reivindicado na tabela de surface — a cerca emlower-calls.tsrecusava com SC2020 antes do dispatch. Fatia inteira era código morto e 2911/2912 vermelhos em todas as lanes.frontend/lowering/surfaces.tsmaxBuffer: 64 * 1024 * 1024recusado:sideEffectFreeOptionValuenão conhecia aritmética.frontend/lowering/surfaces.tsfinishmatava o programa: o dispatch deFinishedlimpavaerror_listenersantes de rodar os listeners de finish, então o ERR_STREAM_WRITE_AFTER_END não tinha ninguém para chamar e era lançado uncaught. A linha de erro e oexitdo filho nunca saíam.runtime-rust/src/child_writer.rsstdin.writablerespondiafalseantes do erro de spawn chegar; Node respondetrue(devolve um ChildProcess vivo e reporta ENOENT num turno posterior).runtime-rust/src/child_writer.rschild.disconnect()não era síncrono:connectedcontinuavatrueesendcontinuava aceitando até uma ida-e-volta na thread do canal. Node fecha o canal dentro dedisconnect(). Causa:connectedacumulava quatro papéis (flag pública, porta do send, guarda de disparo único, e bit de liveness que adia oexitaté o canal drenar), então não podia ser limpo.runtime-rust/src/child_ipc.rsdisconnectchegava depois do callback de um send emitido logo apósdisconnect(); no Node ele vem antes (tick seguinte ao disconnect local, sem ida-e-volta).runtime-rust/src/child_ipc.rswritede 64 KiB + 1 num pipe vazio respondetrueno Node (libuv tenta inline, o kernel absorve os primeiros 64 KiB, só 1 byte conta contra o high-water mark) efalseaqui, porque o writer entrega o buffer inteiro à thread e pontua backpressure por bytes entregues. Concordam em writes grandes, divergem exatamente na fronteira.runtime-rust/src/child_writer.rsO bug 5 foi resolvido separando a flag:
connectedpassa a ser a pública e síncrona, limpa dentro dedisconnect(); um novoopencarrega o estado de dreno, então oexitcontinua adiado e uma mensagem que a thread já havia enquadrado antes do disconnect local continua sendo entregue — a garantia de EOF do plano não mudou.O bug 7 (
2894-child-stdin-backpressure.ts, continua vermelho) precisa de uma tentativa de write não-bloqueante inline na thread principal, enfileirando só o resto — redesenho do writer, e que exige decisão de dependência, já que#![forbid(unsafe_code)]impede chegar no libc direto.3. Corpus por família
2921-zlib-callbacks.ts(corrigido),3278-zlib-callback-invariants.ts(novo)3279-crypto-hash-handles.ts,3280-crypto-random-pbkdf2.ts(novos)2961,2962,3281-emitter-flex-lifecycle.ts(novo)2893,2895,2896,2897,28982894vermelho — bug 7)2911,2912(desbloqueados pelo bug 1)2946,2948,2949,2963,3282-child-fork-disconnect/(novo)50 das 52 libcalls têm agora evidência diferencial verde, confirmada empiricamente: cada fixture foi compilada com
emitIre o IR conferido contra a lista de spellings, não por inspeção visual.Todos os fixtures novos e os dois de execFile levam
// @rust-only. As 52 linhas são["rust"]emBACKEND_LIB_CALLS, ou seja C e LLVM recusariam — é o drift do upstream que o plano manda registrar e não consertar.@rust-onlyé o mecanismo que o próprio repo já usa para isso (198 programas antes deste PR).O que cada fixture novo prova
returned), disparado exatamente uma vez (contador impresso em cada settle), e falha do codec chegando como argumento um sem a chamada lançar. Cobre os quatro decodificadores sobre o mesmo lixo fixo, a falha distinta de gzip truncado, a auto-detecção dounzipnos dois wrappers, cinco deflates concorrentes cada um disparando uma vez, entrada vazia, e o encadeamento semeado do crc32 com seu ERR_OUT_OF_RANGE. Nenhum byte comprimido é impresso (dependem da versão do zlib): todo encoder é provado pelo seu decoder.hash.copy()é testado por independência real: prefixo comum, escrita divergente em cada lado, cada um comparado contra um digest construído à parte; cópia não escrita; e finalizar a cópia sem finalizar a origem. O estado terminal fixa a assimetria do Node: Hash lança ERR_CRYPTO_HASH_FINALIZED em update/digest/copy depois do digest, enquanto Hmac lança no update mas responde string vazia num segundo digest.HMAC(password, salt || INT(1))construído pela própria superfície Hmac, em vez de uma constante copiada. Aleatoriedade só por invariante, nunca por byte: identidade da view retornada, bytes fora de[offset, offset+size)intactos, fill de tamanho zero não toca nada, formas "rest" cobrindo o buffer todo e a partir de um offset, alvoUint8Array, e 32 bytes preenchidos não podendo ser todos zero.randomInté sorteado 400 vezes nas duas aridades, com faixa e integralidade conferidas, e todo valor de uma faixa de largura 3 tem de aparecer — gerador travado ou com viés modular falha aí.prependListenerna frente, identidade da função (mesma referência duas vezes conta duas, dispara duas, eoffremove uma por vez),once, e remoção durante a emissão nas três variantes (remover outro, remover a si mesmo,removeAllListeners) — o listener removido ainda roda na emissão em voo, porque o Node despacha sobre um snapshot. Mais: nome literal casado pelo padrão computado caindo no mesmo bucket, e oerrorreservado permanecendo no bucket tipado.child.disconnect(todos os programas existentes desconectam pelo lado do worker) e ochild.send(mensagem)sem callback (todos os existentes passam um, o que roteia parachild.sendCb). Distingue canal ausente (filho despawn, sem slot IPC) de canal desconectado; rajada de mensagens enquadradas que o worker reporta inteira mesmo com o canal derrubado logo após a resposta; os dois callbacks de send assentando exatamente uma vez; e o worker escrevendo o exit code duas vezes para o pai confirmar que observa a última escrita.4. As 2 libcalls que ficaram abertas
process.setExitCodeeprocess.currentExitCodenão têm produtor no frontend — são inalcançáveis a partir de TypeScript, então não podem receber uma linha de corpus:process.exitCode = nlowera paraprocess.exitCodeSet, eprocess.setExitCodeemite a mesma função de runtime (runtime::process_exit_code_set,backend/rust/process.ts:15-20): é um alias redundante.process.exitCodenão tem lowering nenhum — verificado: recusa comSC2020 'process.exitCode' is part of the standard library types but has no scriptc lowering yet.A propriedade que o plano pede para elas ("mesma fonte de verdade que
process.exitCodeSet") está verificada de forma indireta: compartilham o único escritor no runtime, e 3282 observa last-write-wins de fora. Recomendação para o mantenedor: ou remover o alias, ou adicionar o lowering da leitura deprocess.exitCodee então cobri-las.5. Gates
cargo clippy --all-targets --locked -- -D warningscargo test --lockedpnpm manifest --checkpnpm gen:backend-libcalls --checkpnpm node-compat:checkpnpm -r buildtests/harness/diagnostics.test.ts(131 fixtures)pnpm test/SCRIPTC_SAN=1)Rebaseado em
8d5c8962, então este branch já está no mundo pós-remoção do harness de Vercel Sandbox (PR #26): não hátest:sandboxpara rodar, e o item 5 da Onda 6 agora manda rodar as duas lanes locais.Essas lanes completas não foram executadas, por combinação explícita com o mantenedor: são horas nesta máquina e o cgroup de 3 GiB do
pnpm limitmata o rustc por OOM. O gate entregue é o subconjunto focado acima — geradores,cargo test/clippy, build do workspace, o differential rust das seis famílias e o suite de diagnostics inteiro.O differential das famílias rodou os 36 programas que tocam zlib, crypto, emitter, child stdin, execFile e fork/IPC. Os 3 vermelhos são
2894(bug 7, aberto),2890-crypto-static-utility.tse3144-zlib-compression-views.ts— os dois últimos já vermelhos emmainpor formas sem lowering, detalhados abaixo. Os 7 fixtures novos/corrigidos passam todos.Prova de não-regressão da mudança no frontend.
surfaces.tsé compartilhado, então rodei o suite de diagnostics inteiro (131 fixtures) nas duas pontas: revertendo minha mudança e recompilando dá 24 falhas; com ela, 23. A única diferença échild-process.ts, que sai do vermelho porque regenerei o snapshot dele. Nenhum fixture passou a falhar. A extensão desideEffectFreeOptionValuetambém é monotônica por construção: ela só decide recusas, então um programa que já compilava não pode passar a falhar por causa dela.6. Dívida pré-existente que a onda revelou (fora do escopo deste PR)
Rodar o corpus pela primeira vez mostrou que a lane rust já estava vermelha em main por motivos alheios às 52 libcalls. Registrado aqui, não corrigido:
tests/harness/diagnostics.test.ts: 23 snapshots desatualizados continuam vermelhos (eram 24 emmain), verificado pela comparação de conjuntos descrita acima:assert-fences,async-iterator-handles,cjs-module-mutation,component-fences,create-require,dataview,dynamic-import-static,intersection-values,json-dyn,maps-surface,maps,node-fallback-fence,overload-fences,overloaded-function-values,record-width,regex-checker,regex-function-replacement,regex,sets,spread-fences,stdlib-fence,stdlib,tuples. As ondas de implementação atualizaram os fixtures e nunca regeneraram os snapshots. Sóchild-process.tsfoi regenerado aqui, porque a correção do execFile muda legitimamente a saída dele — e o fixture já tinha sido escrito para o mundo pós-onda-4 (as cercas por sítioexecFile with 1 arguments,fork without a fourth-slot IPC channeletc. agora aparecem em vez de uma recusa única do membro).2913-builtin-callable-alias.tsvermelho: alias imutável de builtin tabelado (const pack = deflateSync;) recusado com SC1090 "library functions as values". O programa foi escrito para uma feature que hoje é recusada.3008-deflate-fixed-level.tse3144-zlib-compression-views.tsvermelhos:deflateSynccom opções explícitas (zlib.deflateSyncLevel) sem lowering no frontend.2890-crypto-static-utility.tsvermelho por quatro formas distintas sem lowering, entre elas opbkdf2assíncrono com callback (crypto.pbkdf2Cbé nomeado pelo emitter LLVM e não tem produtor no frontend — outro órfão) e a cadeia fundida que cerca em vez de cair para o caminho de handle quando a forma não fecha, o que bloqueiacreateHash("sha1").update("x").digest(), uma grafia Node perfeitamente comum. As 16 libcalls de crypto ficaram cobertas por 3279/3280 sem depender dessas formas.2877-child-unknown-roundtrip.ts,2912-promisify-fs-readfile.ts,2939-child-shell-text.ts,2940-fsp-realpath.ts,2941-fsp-lstat-identity.ts,2950-code-point-at.ts,2950-fs-descriptor-publication.ts,2966-numeric-array-read-fusion.ts.Não mergeado de propósito.
🤖 Generated with Claude Code
https://claude.ai/code/session_0197JoEpMBBqqkSiBb2vNX5A